// PanasonicHardware.cs
//
// Implements HomeUX.Components.PanasonicHardware.
//

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.Linq;
using System.Text.RegularExpressions;
using HomeUX.Utilities;

namespace HomeUX.Components
{

/// <summary>
/// Accesses the telephone log of a Panasonic D816 Digital Super Hybrid System telephone PBX
/// connected via a serial port.  Compared to <r>PanasonicDriver</r>, <n>PanasonicHardware</n>
/// provides a lower-level hardware-specific interface.
/// </summary>
///
/// <remarks>
///
/// <para>Only a subset of the full functionality of the hardware can be accessed by this class.
/// </para>
///
/// <para>General usage pattern:</para>
///
/// <list type="number">
/// 	<item><description>Construct an instance of this class.</description></item>
///		<item><description>Hook up events.</description></item>
///		<item><description>Call <s>SimpleSerialHardware{}.Open</s> (inherited from
///			<r>SimpleSerialHardware{}</r>).</description></item>
///		<item><description>When telephone calls are placed or received, the PhoneCallReported event
/// 		will be fired.  Note that this hardware cannot be controlled via the serial port -- all
/// 		you can do is get notifications of phone calls.</description></item>
/// </list>
///
/// <para>Philosophy about exceptions:</para>
///
/// <list type="bullet">
///		<item><description>Programmer errors can lead to exceptions like <n>ArgumentException</n>.
/// 		</description></item>
///		<item><description>Hardware problems (including timeouts) won't lead to exceptions, but
///			rather will cause the following to occur:
///			<list type="bullet">
///				<item><description>Events such as <r>PhoneCallReported</r> and
/// 				<r>WarningsReported</r> will be fired as appropriate.  This lets you centralize
/// 				reporting of hardware problems to the user.</description></item>
///			</list>
///			</description></item>
///	</list>
///
/// </remarks>
///
[DebuggerDisplay("PanasonicHardware {HardwareLabel}")]
public class PanasonicHardware : SimpleSerialHardware<GenericReply>
{
    ///////////////////////////////////////////////////////////////////////////////////////////////
    // Private Constants
    //

    /// <summary>
    /// The message to display when the hardware sends something unexpected via the serial port.
    /// </summary>
    const string UnexpectedOutputMessage = "Unexpected output from hardware: {0}";

    ///////////////////////////////////////////////////////////////////////////////////////////////
    // Public Events
    //

    /// <summary>
    /// Fired when a phone call is placed or received.
    /// </summary>
    public event EventHandler<PanasonicHardwarePhoneCallReportedEventArgs> PhoneCallReported;

    /// <summary>
    /// Fired when this class has warning(s) to report to the application.  These warnings can be
	/// displayed to the user.
    /// </summary>
    public event EventHandler<PanasonicHardwareWarningsReportedEventArgs> WarningsReported;

    ///////////////////////////////////////////////////////////////////////////////////////////////
    // Public Methods & Related Private Methods
    //

    /// <summary>
    /// Initializes an instance of this class.
    /// </summary>
    ///
    /// <param name="workQueue">The <r>WorkQueue</r> to use for scheduling asynchronous tasks.
	/// 	</param>
    ///
    /// <param name="serialPort">The serial port (real or virtual) to connect to.</param>
    ///
    /// <param name="hardwareLabel">A label to use for referring to this hardware device within
	/// 	warnings, trace output, etc.</param>
    ///
    public PanasonicHardware(WorkQueue workQueue, VirtualSerialPort serialPort,
			string hardwareLabel) :
        base(workQueue, serialPort, SimpleSerialHardwareMode.Line, hardwareLabel)
    {
        serialPort.NewLine = "\r\n";
    }

    ///////////////////////////////////////////////////////////////////////////////////////////////
    // SimpleSerialHardware Overrides & Related Methods
    //

    /// <summary>
    /// Overrides <r>SimpleSerialHardware{}.ParseReceivedString</r>.
    /// </summary>
    ///
    /// <param name="received">See <r>SimpleSerialHardware{}.ParseReceivedString</r>.</param>
    /// 
    /// <param name="pushBack">See <r>SimpleSerialHardware{}.ParseReceivedString</r>.</param>
    /// 
    /// <param name="currentCommand">See <r>SimpleSerialHardware{}.ParseReceivedString</r>.</param>
    /// 
    /// <param name="onReply">See <r>SimpleSerialHardware{}.ParseReceivedString</r>.</param>
    ///
    protected override void ParseReceivedString(string received, ref string pushBack,
        string currentCommand, Action<GenericReply> onReply)
    {
		// As soon as the serial port is connected to, the hardware sends bytes 0x00 and 0xF8
		// and/or 0xFF, sometimes followed by 0x13, then a fraction of a second later it sends
		// 0x11.  Then, 5-10 seconds later, it sends "ATQ0E0V1S0=1X0&D0\r\n".
		//
		// The next time an incoming or outgoing call occurs, the hardware sends a header followed
		// by a log entry.  Subsequent calls have log entries, but the header is only included on
		// the first call, and after each 60-70 calls.
		//
		// Header format:
		//
        //   Date     Time    Ext CO        Dial Number        Ring Duration  Acc code  CD
        // --------------------------------------------------------------------------------
		//
		// Example of a log entry (the ".1234..." line is not included -- it's just a "ruler"):
		//
		// 12/18/06 05:40PM   105 01 <I>2065551234  JOHNS II,G 0'13 00:00'15
		// .123456789.123456789.123456789.123456789.123456789.123456789.123456789.123456789
		//
		// Log entries have a fixed format:
		// 
		// Columns	Examples		Description
		// -------  --------        -----------
		//
		// 0-7		12/18/06		Date in "MM/DD/YY" format.
		//
		// 9-15		05:40PM			Time in "HH:MMXX" format.
		//
		// 17-21	105				Extension that picked up the incoming call or dialed the
		//							outgoing call.  Blank for an incoming call that wasn't picked
		//							up.
		//
		// 23-24 	01				Line number.
		//
		// 26-39	<I>2065551234	"<I>" indicates an incoming call (vs. an outgoing call); it's
		// 			<I>				followed by the number unless the caller hung up before caller
		// 			8828080 		ID information was transmitted.  No "<I>" means it's an
		// 			1-425-555-1234 	outgoing call.
		// 			-206-555-1234
		//
		// 41-50 	JOHNS II,G		Caller ID name, if available; blank or "PRIVATE" if not.
		// 			QWEST COMM
		// 			OUT OF ARE
		// 			BRIT COLUM
		// 			PRIVATE
		//
		// 52-55 	0'01			Ring duration in "minutes'seconds" format.  Blank for outgoing
		// 			0'20			calls.
		//
		// 57-64	00:00'02		Call duration in "hours:minutes'seconds" format.  Blank for
		// 			01:09'25		unanswered incoming calls.
		//
		// 66-79					Other information -- TBD.
		//
		// There's a blank between each of the fields above.
		//

        // if we come across miscellaneous issue(s) to report (e.g. the driver sends text we
        // don't expect), <warnings> will be set to the issue(s) to report, otherwise it
        // will be null
        LazyStringList warnings = new LazyStringList();

        // lines of text sent by the hardware end with "\r\n"; we'll delete the "\r" characters
        // and look for the "\n" characters
        received = received.Replace("\r", String.Empty);

		// parse <received>; set <phoneCalls> to the phone call events; add warnings to <warnings>;
		// set <pushBack> to the incomplete last line (or String.Empty if none)
		var phoneCalls = new List<PanasonicHardwarePhoneCallReportedEventArgs>();
        string[] lines = received.Split('\n');
        pushBack = lines[lines.Length - 1];
        foreach (string line in lines.Where((l, i) => i < lines.Length - 1))
        {
			// ignore the line if it's blank
			if (line.Length == 0)
				continue;

            // ignore the line if it's the initialization line
            if (line.Contains("ATQ0E0V1S0"))
                continue;

            // ignore the line if it's a page header
            if (line.Contains("Date") && line.Contains("Time") && line.Contains("Ext") &&
					line.Contains("CO") && line.Contains("Dial Number"))
                continue;

            // ignore the line if it's the separator after a page header
			if (line.Contains("----------------------------------------"))
				continue;

			// the line should be a record of an incoming or outgoing phone call -- parse it and
            // add it to <phoneCalls>
            try
            {
                PanasonicHardwarePhoneCallReportedEventArgs phoneCall =
                    ParsePhoneCallLogEntry(line);
                phoneCalls.Add(phoneCall);
            }
            catch (NonfatalException ex)
            {
                warnings.Add("Error in phone log entry \"{0}\": {1}", line, ex.Message);
            }
        }

        // report warnings, if any
        ReportWarnings(warnings);

        // report phone calls, if any
        ReportPhoneCalls(phoneCalls);
    }

    /// <summary>
    /// Parses a line of text from the hardware which presumably contains a record of an incoming
    /// or outgoing phone call.
    /// </summary>
    ///
    /// <param name="line">The phone call record from the hardware.</param>
    ///
    /// <exception cref="NonfatalException">
    /// The format of the line is incorrect, or the data within it is invalid.
    /// </exception>
    ///
	PanasonicHardwarePhoneCallReportedEventArgs ParsePhoneCallLogEntry(string line)
	{
		// create a new PanasonicHardwarePhoneCallReportedEventArgs to hold information about the
		// incoming or outgoing phone call
		var phoneCall = new PanasonicHardwarePhoneCallReportedEventArgs();

		// Example of <line>:
		// 12/18/06 05:40PM   105 01 <I>2065551234  JOHNS II,G 0'13 00:00'15
		// .123456789.123456789.123456789.123456789.123456789.123456789.123456789.123456789

		// parse the date and time into <phoneCall.DateTime>
		const int dateTimeOffset = 0;
		uint month = ParseUintField(line, dateTimeOffset, 2);
		ExpectField(line, 2, 1, "/");
		uint day = ParseUintField(line, 3, 2);
		ExpectField(line, 5, 1, "/");
		uint year = ParseUintField(line, 6, 2);
		uint hour = ParseUintField(line, 9, 2);
		ExpectField(line, 11, 1, ":");
		uint minute = ParseUintField(line, 12, 2);
		const int amPmOffset = 14;
		string amPm = ParseField(line, amPmOffset, 2);
		if (amPm == "AM")
		{
			if (hour == 12)
				hour = 0;
		}
		else
		if (amPm == "PM")
		{
			if (hour != 12)
				hour += 12;
		}
		else
		{
			throw new NonfatalException(
				"Offset {0}: Expected \"AM\" or \"PM\", found \"{1}\"",
				amPmOffset, amPm);
		}
		try
		{
			phoneCall.DateTime = new DateTime(2000 + (int)year, (int)month, (int)day,
				(int)hour, (int)minute, 0);
		}
		catch (ArgumentException)
		{
			throw new NonfatalException(
				"Offset {0}: Expected a date and time",
				dateTimeOffset);

		}

		// parse the phone extension number into <phone.Extension>
		phoneCall.Extension = (int)ParseUintField(line, 17, 5);

		// parse the line number into <phone.LineNumber>
		phoneCall.LineNumber = (int)ParseUintField(line, 23, 2);

		// set <phone.IsIncoming> to indicate whether this is an incoming (vs. outgoing)
		// phone call; parse the phone number into <phone.PhoneNumber>
		string phoneNumber = ParseField(line, 26, 14);
		if (phoneNumber.StartsWith("<I>"))
		{
			phoneCall.IsIncoming = true;
			phoneNumber = phoneNumber.Substring(3);
		}
		phoneCall.PhoneNumber = phoneNumber;

		// parse the caller ID (if available) into <phone.CallerId>
		phoneCall.CallerId = ParseField(line, 41, 10);

		// parse the ring duration (if available) into <phone.RingDuration>
		if (phoneCall.IsIncoming)
			phoneCall.RingDuration = ParseDurationField(line, 52, 4);

		// parse the call duration into <phone.CallDuration>
		phoneCall.CallDuration = ParseDurationField(line, 57, 8);

		// done
		return phoneCall;
	}

    /// <summary>
    /// Returns a substring of a given line.
    /// </summary>
    ///
    /// <param name="line">The line.</param>
    ///
    /// <param name="startIndex">The index of the first character of the substring.</param>
    ///
    /// <param name="length">The length of the substring.</param>
    ///
    /// <exception cref="NonfatalException">
    /// The line isn't long enough to contain the specified substring.
    /// </exception>
    ///
    static string ParseField(string line, int startIndex, int length)
    {
        try
        {
            return line.Substring(startIndex, length).Trim();
        }
        catch (ArgumentOutOfRangeException)
        {
            throw new NonfatalException("Offset {0}, length {1}: Missing field",
                startIndex, length);
        }
    }

    static void ExpectField(string line, int startIndex, int length, string expectedString)
    {
        string stringValue = ParseField(line, startIndex, length);
        if (stringValue != expectedString)
        {
            throw new NonfatalException(
                "Offset {0}: Expected \"{1}\", found \"{2}\"",
                startIndex, expectedString, stringValue);
        }
    }

    /// <summary>
    /// Returns a substring of a given line, converted to an unsigned integer.
    /// </summary>
    ///
    /// <param name="line">The line.</param>
    ///
    /// <param name="startIndex">The index of the first character of the substring.</param>
    ///
    /// <param name="length">The length of the substring.</param>
    ///
    /// <exception cref="NonfatalException">
    /// The line isn't long enough to contain the specified substring, or the substring isn't an
    /// unsigned integer.
    /// </exception>
    ///
    static uint ParseUintField(string line, int startIndex, int length)
    {
        string stringValue = ParseField(line, startIndex, length);
        uint result;
        if (uint.TryParse(stringValue,
            NumberStyles.AllowLeadingWhite | NumberStyles.AllowTrailingWhite,
            System.Globalization.CultureInfo.InvariantCulture, out result))
        {
            return result;
        }
        else
        {
            throw new NonfatalException(
                "Offset {0}: Expected unsigned integer field, found \"{1}\"",
                startIndex, stringValue);
        }
    }

    /// <summary>
    /// Returns a substring of a given line, converted from hardware duration format (e.g.
    /// "<i>hours</i>:<i>minutes</i>'<i>seconds</i>" or "<i>minutes</i>'<i>seconds</i>") to a
    /// <n>TimeSpan</n>.
    /// </summary>
    ///
    /// <param name="line">The line.</param>
    ///
    /// <param name="startIndex">The index of the first character of the substring.</param>
    ///
    /// <param name="length">The length of the substring.</param>
    ///
    /// <exception cref="NonfatalException">
    /// The line isn't long enough to contain the specified substring, or the substring isn't a
    /// duration.
    /// </exception>
    ///
    static TimeSpan ParseDurationField(string line, int startIndex, int length)
    {
        // set <stringValue> to the string value of the field
        string stringValue = ParseField(line, startIndex, length);

        try
        {
            // parse the hours portion, if any, from <stringValue> into <hours>; set <index> to
            // the index of the "<minutes>'<seconds>" portion of the string
            int index = stringValue.IndexOf(':');
            uint hours;
            if (index > 0)
            {
                if (!uint.TryParse(stringValue.Substring(0, index),
                    NumberStyles.AllowLeadingWhite | NumberStyles.AllowTrailingWhite,
                    System.Globalization.CultureInfo.InvariantCulture, out hours))
                {
                    throw new NonfatalException();
                }
                index++;
            }
            else
            {
                hours = 0;
                index = 0;
            }

            // parse the minutes and seconds portion from <stringValue> into <minutes> and
            // <seconds>
            uint minutes, seconds;
            int quoteIndex = stringValue.IndexOf('\'', index);
            if (quoteIndex < 0)
                throw new NonfatalException();
            if (!uint.TryParse(stringValue.Substring(index, quoteIndex - index),
                NumberStyles.AllowLeadingWhite | NumberStyles.AllowTrailingWhite,
                System.Globalization.CultureInfo.InvariantCulture, out minutes))
            {
                throw new NonfatalException();
            }
            if (!uint.TryParse(stringValue.Substring(quoteIndex + 1),
                NumberStyles.AllowLeadingWhite | NumberStyles.AllowTrailingWhite,
                System.Globalization.CultureInfo.InvariantCulture, out seconds))
            {
                throw new NonfatalException();
            }

            // return the duration
            try
            {
                return new TimeSpan((int)hours, (int)minutes, (int)seconds);
            }
            catch (ArgumentException)
            {
                throw new NonfatalException();
            }
        }
        catch (NonfatalException)
        {
            throw new NonfatalException(
                "Offset {0}: Expected a duration, found \"{1}\"",
                startIndex, stringValue);
        }
    }

    /// <summary>
    /// Fires <r>PhoneCallsReported</r> to inform the application about a given list of phone
	/// calls.
    /// </summary>
    ///
    /// <param name="phoneCalls">The phone calls to report.</param>
    ///
	public void ReportPhoneCalls(List<PanasonicHardwarePhoneCallReportedEventArgs> phoneCalls)
	{
        if (PhoneCallReported != null)
        {
			foreach (PanasonicHardwarePhoneCallReportedEventArgs phoneCall in phoneCalls)
				PhoneCallReported(this, phoneCall);
        }
	}

    /// <summary>
    /// Fires <r>WarningsReported</r> as needed to inform the application about warnings in a given
	/// list.
    /// </summary>
    ///
    /// <param name="warnings">The warnings, if any.</param>
    ///
	public void ReportWarnings(LazyStringList warnings)
	{
        if ((warnings.Count > 0) && (WarningsReported != null))
        {
            WarningsReported(this, new PanasonicHardwareWarningsReportedEventArgs()
            {
                Messages = warnings.ListOrNull
            });
        }
	}
}

/// <summary>
/// Arguments of the <r>PanasonicHardware.PhoneCallReported</r> event.
/// </summary>
///
[DebuggerDisplay("{GetDebuggerDisplay()}")]
public class PanasonicHardwarePhoneCallReportedEventArgs : EventArgs
{
    /// <summary>
    /// The date and time of the phone call.
    /// </summary>
    public DateTime DateTime { get; internal set; }

    /// <summary>
    /// The extension (e.g. 101) that placed or received the phone call.
    /// </summary>
    public int Extension { get; internal set; }

    /// <summary>
    /// The line number (e.g. 1) that placed or received the phone call.
    /// </summary>
    public int LineNumber { get; internal set; }

    /// <summary>
    /// Indicates if this is an incoming phone call (vs. an outgoing call).
    /// </summary>
    public bool IsIncoming { get; internal set; }

    /// <summary>
    /// The phone number, or <n>String.Empty</n> if it's not known.
    /// </summary>
    public string PhoneNumber { get; internal set; }

    /// <summary>
    /// The caller ID, or <n>String.Empty</n> if it's not known.
    /// </summary>
    public string CallerId { get; internal set; }

    /// <summary>
    /// Ring duration, for incoming calls.  Not used for incoming calls.
    /// </summary>
    public TimeSpan RingDuration { get; internal set; }

    /// <summary>
    /// The duration of the phone call.
    /// </summary>
    public TimeSpan CallDuration { get; internal set; }

    /// <summary>
    /// Returns a value used by <c>[DebuggerDisplay]</c>, encapsulated to prevent the debugger
    /// from displaying it in quotes.
    /// </summary>
    object GetDebuggerDisplay()
    {
        return new EncapsulatedString(
            (IsIncoming ? "Incoming call from {0}" : "Outgoing call to {0}"),
            PhoneNumber);
    }
}

/// <summary>
/// Arguments of the <r>PanasonicHardware.WarningsReported</r> event.
/// </summary>
///
[DebuggerDisplay("Count = {Messages.Count}")]
public class PanasonicHardwareWarningsReportedEventArgs : EventArgs
{
    /// <summary>
    /// Gets the warnings that were reported.
    /// </summary>
    public ICollection<string> Messages { get; internal set; }
}

}

